﻿//
// (C) Copyright 2009 Irantha Suwandarathna (irantha@gmail.com)
// All rights reserved.
//

/* Copyright (c) 2001-2008, The HSQL Development Group
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 *
 * Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 *
 * Neither the name of the HSQL Development Group nor the names of its
 * contributors may be used to endorse or promote products derived from this
 * software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL HSQL DEVELOPMENT GROUP, HSQLDB.ORG,
 * OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using EffiProzDB;
using EffiProzDB.Lib;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace EffiProzDBTest.SelfTest
{
   /**
 * Utility class providing methodes for submitting test statements or
 * scripts to the database, comparing the results returned with
 * the expected results. The test script format is compatible with existing
 * scripts.
 *
 * @author ewanslater@users
 * @author fredt@users
 */
public class TestUtil {

    /**
     * Runs a preformatted script.<p>
     *
     * Where a result set is required, each line in the script will
     * be interpreted as a seperate expected row in the ResultSet
     * returned by the query.  Within each row, fields should be delimited
     * using either comma (the default), or a user defined delimiter
     * which should be specified in the System property TestUtilFieldDelimiter
     * @param aConnection Connection object for the database
     * @param aPath Path of the script file to be tested
     */

    public static void testScript(SessionInterface aConnection, String aPath) {

        try {
            SessionInterface statement = aConnection;
            FileInfo testfile = new FileInfo(aPath);
            StreamReader reader = testfile.OpenText();
        
            List<string> section = null;

            print("Opened test script file: " + testfile.FullName);

            /**
             * we read the lines from the start of one section of the script "/*"
             *  until the start of the next section, collecting the lines
             *  in the Vector lines.
             *  When a new section starts, we will pass the vector of lines
             *  to the test method to be processed.
             */
            int startLine = 0;
            int currentLine = 0;

            while (true) {
                bool startSection = false;
                String  line         = reader.ReadLine();
                currentLine++;

                if (line == null) {
                    break;
                }

                line = line.Substring( 0, StringUtil.rTrimSize(line));

                //if the line is blank or a comment, then ignore it
                if ((line.Length == 0) || line.StartsWith("--")) {
                    continue;
                }

                //...check if we're starting a new section...
                if (line.StartsWith("/*")) {
                    startSection = true;
                }

                if (line[0] != ' ' && line[0] != '*') {
                    startSection = true;
                }

                if (startSection) {

                    //...if we are, test the previous section (if it exists)...
                    if (section != null) {
                        testSection(statement, section, startLine);
                    }

                    //...and then start a new section...
                    section   = new List<string>();
                    startLine = currentLine;
                }

                section.Add(line);
            }

            //send the last section for testing
            if (section != null) {
                testSection(statement, section, startLine);
            }

            statement.close();
            print("Processed lines: " + startLine);
        } catch (Exception e) {
            print(e.StackTrace);
            Assert.Fail("test script file error: " + e.Message + " " + e.StackTrace);
        }
    }

    /**
     * Performs a preformatted statement or group of statements and throws
     *  if the result does not match the expected one.
     * @param line start line in the script file for this test
     * @param stat Statement object used to access the database
     * @param s Contains the type, expected result and SQL for the test
     */
    static void test(SessionInterface stat, String s, int line) {

        //maintain the interface for this method
        List<string> section = new List<string>();

        section.Add(s);
        testSection(stat, section, line);
    }

    /**
     * Method to save typing ;-)
     * @param s String to be printed
     */
    static void print(String s) {
        System.Diagnostics.Debug.WriteLine(s);
        System.Diagnostics.Debug.Flush();
        
    }

    /**
     * Takes a discrete section of the test script, contained in the
     * section vector, splits this into the expected result(s) and
     * submits the statement to the database, comparing the results
     * returned with the expected results.
     * If the actual result differs from that expected, or an
     * exception is thrown, then the appropriate message is printed.
     * @param stat Statement object used to access the database
     * @param section Vector of script lines containing a discrete
     * section of script (i.e. test type, expected results,
     * SQL for the statement).
     * @param line line of the script file where this section started
     */
    private static void testSection(SessionInterface stat, List<string> section,
                                    int line) {

        //create an appropriate instance of ParsedSection
        ParsedSection pSection = parsedSectionFactory(section);

        if (pSection == null) {    //it was not possible to sucessfully parse the section
            Assert.Fail("The section starting at line " + line
                  + " could not be parsed, " + "and so was not processed.\n");
        } else if (pSection is IgnoreParsedSection) {
            print("Line " + line + ": " + pSection.getResultString());
        } else if (pSection is DisplaySection) {

            // May or may not want to report line number for 'd' sections.  ?
            print(pSection.getResultString());
        } else if (!pSection.test(stat)) {            
            print("section starting at line " + line);
            print("returned an unexpected result:");
            print(pSection.ToString());
            Assert.Fail("section starting at line " + line + "returned an unexpected result:" + pSection.ToString());
        }
    }

    /**
     * Factory method to create appropriate parsed section class for the section
     * @param aSection Vector containing the section of script
     * @return a ParesedSection object
     */
    private static ParsedSection parsedSectionFactory(
            List<string> aSection) {

        //type of the section
        char type = ' ';

        //section represented as an array of Strings, one for each significant
        //line in the section
        String[] rows = null;

        //read the first line of the Vector...
        String topLine =  aSection[0];

        //...and check it for the type...
        if (topLine.StartsWith("/*")) {
            type = topLine[2];

            //if the type code is invalid return null
            if (!ParsedSection.isValidCode(type)) {
                return null;
            }

            //if the type code is UPPERCASE and system property IgnoreCodeCase
            //has been set to true, make the type code lowercase
            if (Char.IsUpper(type)) {
                type = char.ToLower(type);
            }

            //...strip out the type declaration...
            topLine = topLine.Substring(3);
        }

        //if, after stripping out the declaration from topLine, the length of topLine
        //is greater than 0, then keep the rest of the line, as the first row.
        //Otherwise it will be discarded, and the offset (between the array and the vector)
        //set to 1.
        int offset = 0;

        if (topLine.Trim().Length > 0) {
            rows    = new String[aSection.Count];
            rows[0] = topLine;
        } else {
            rows   = new String[aSection.Count - 1];
            offset = 1;
        }

        //pull the rest of aSection into the rows array.
        for (int i = (1 - offset); i < rows.Length; i++) {
            rows[i] = (String) aSection[i + offset];
        }

        //then pass this to the constructor for the ParsedSection class that
        //corresponds to the value of type
        switch (type) {

            case 'u' :
                return new UpdateParsedSection(rows);

            case 's' :
                return new SilentParsedSection(rows);

            case 'r' :
                return new ResultSetParsedSection(rows);

            case 'c' :
                return new CountParsedSection(rows);

            case 'd' :
                return new DisplaySection(rows);

            case 'e' :
                return new ExceptionParsedSection(rows);

            case ' ' :
                return new BlankParsedSection(rows);

            default :

                //if we arrive here, then we should have a valid code,
                //since we validated it earlier, so return an
                //IgnoreParsedSection object
                return new IgnoreParsedSection(rows, type);
        }
    }
}

/**
 * Abstract inner class representing a parsed section of script.
 * The specific ParsedSections for each type of test should inherit from this.
 */
abstract class ParsedSection {

    /**
     * Type of this test.
     * @see isValidCase() for allowed values
     */
    protected char type = ' ';

    /** error message for this section */
    public String message = null;

    /** contents of the section as an array of Strings, one for each line in the section. */
    protected String[] lines = null;

    /** number of the last row containing results in sectionLines */
    protected int resEndRow = 0;

    /** SQL query to be submitted to the database. */
    protected String sqlString = null;

    /**
     * Constructor when the section's input lines do not need to be parsed
     * into SQL.
     */
    protected ParsedSection() {}

    /**
     * Common constructor functions for this family.
     * @param aLines Array of the script lines containing the section of script.
     * database
     */
    protected ParsedSection(String[] aLines) {

        lines = aLines;

        //read the lines array backwards to get out the SQL String
        //using a StringBuilder for efficency until we've got the whole String
        StringBuilder sqlBuff  = new StringBuilder();
        int          endIndex = 0;
        int          k        = lines.Length - 1;

        do {

            //check to see if the row contains the end of the result set
            if ((endIndex = lines[k].IndexOf("*/")) != -1) {

                //then this is the end of the result set
                sqlBuff.Insert(0, lines[k].Substring(endIndex + 2));

                lines[k] = lines[k].Substring(0, endIndex);

                if (lines[k].Length == 0) {
                    resEndRow = k - 1;
                } else {
                    resEndRow = k;
                }

                break;
            } else {
                sqlBuff.Insert(0, lines[k]);
            }

            k--;
        } while (k >= 0);

        //set sqlString value
        sqlString = sqlBuff.ToString();
    }

    /**
     * String representation of this ParsedSection
     * @return String representation of this ParsedSection
     */
    public override String ToString() {

        StringBuilder b = new StringBuilder();

        b.Append("\n******\n");
        b.Append("contents of lines array:\n");

        for (int i = 0; i < lines.Length; i++) {
            if (lines[i].Trim().Length > 0) {
                b.Append("line ").Append(i).Append(": ").Append(
                    lines[i]).Append("\n");
            }
        }

        b.Append("Type: ");
        b.Append(getType()).Append("\n");
        b.Append("SQL: ").Append(getSql()).Append("\n");
        b.Append("results:\n");
        b.Append(getResultString());

        //check to see if the message field has been populated
        if (getMessage() != null) {
            b.Append("\nmessage:\n");
            b.Append(getMessage());
        }

        b.Append("\n******\n");

        return b.ToString();
    }

    /**
     * returns a String representation of the expected result for the test
     * @return The expected result(s) for the test
     */
    internal abstract String getResultString();

    /**
     *  returns the error message for the section
     *
     * @return message
     */
    protected String getMessage() {
        return message;
    }

    /**
     * returns the type of this section
     * @return type of this section
     */
    protected char getType() {
        return type;
    }

    /**
     * returns the SQL statement for this section
     * @return SQL statement for this section
     */
    protected String getSql() {
        return sqlString;
    }

    /**
     * performs the test contained in the section against the database.
     * @param aStatement Statement object
     * @return true if the result(s) are as expected, otherwise false
     */
    internal virtual bool test(SessionInterface aStatement) {

        Result resultOut = new Result(ResultConstants.SQLEXECDIRECT);
        resultOut.setMainString(getSql());
        resultOut.setMaxRows(1000);

        Result resultIn = null;

        try {
            resultIn = aStatement.execute(resultOut);
        } catch (Exception x) {
            message = x.Message;

            return false;
        }
        if (resultIn.isError())
        {

            message = resultIn.getMainString() + "\n "
                + resultIn.getException() == null ? "" : resultIn.getException().Message + "\n " 
                +resultIn.getException()==null?"": resultIn.getException().StackTrace;
            return false;
        }

        return true;
    }

    /**
     * Checks that the type code letter is valid
     * @param aCode type code to validate.
     * @return true if the type code is valid, otherwise false.
     */
    internal static bool isValidCode(char aCode) {

        /* Allowed values for test codes are:
         * (note that UPPERCASE codes, while valid are only processed if the
         * system property IgnoreCodeCase has been set to true)
         *
         * 'u' ('U') - update
         * 'c' ('C') - count
         * 'e' ('E') - exception
         * 'r' ('R') - results
         * 's' ('S') - silent
         * 'd'       - display   (No reason to use upper-case).
         * ' ' - not a test
         */
        char testChar = Char.ToLower(aCode);

        switch (testChar) {

            case ' ' :
            case 'r' :
            case 'e' :
            case 'c' :
            case 'u' :
            case 's' :
            case 'd' :
                return true;
        }

        return false;
    }
}

/** Represents a ParsedSection for a ResultSet test */
class ResultSetParsedSection : ParsedSection {

    private String delim =  ",";
    private String[] expectedRows = null;

    /**
     * constructs a new instance of ResultSetParsedSection, interpreting
     * the supplied results as one or more lines of delimited field values
     * @param lines String[]
     */
    internal ResultSetParsedSection(String[] lines)  :base(lines){

        

        type = 'r';

        //now we'll populate the expectedResults array
        expectedRows = new String[(resEndRow + 1)];

        for (int i = 0; i <= resEndRow; i++) {
            int skip = StringUtil.skipSpaces(lines[i], 0);

            expectedRows[i] = lines[i].Substring(skip);
        }
    }

    internal override String getResultString() {

        StringBuilder printVal = new StringBuilder();

        for (int i = 0; i < getExpectedRows().Length; i++) {
            printVal.Append(getExpectedRows()[i]).Append("\n");
        }

        return printVal.ToString();
    }

    internal override bool test(SessionInterface aStatement)
    {

        Result resultOut = new Result(ResultConstants.SQLEXECDIRECT);
        resultOut.setMainString(getSql());
        resultOut.setMaxRows(1000);

        Result resultIn = null;

        try {
            try {

                //execute the SQL
               resultIn = aStatement.execute(resultOut);
            } catch (Exception s) {
                throw new Exception(
                    "Expected a ResultSet, but got the error: "
                    + s.Message);
            }
            if (resultIn.isError())
            {
                 throw  new Exception( resultIn.getMainString() + "\n " + resultIn.getException().Message + 
                     "\n " + resultIn.getException().StackTrace);
                    
            }

            //check that update count != -1
            if (resultIn.updateCount != -1)
            {
                throw new Exception(
                    "Expected a ResultSet, but got an update count of "
                    + resultIn.updateCount);
            }

            //iterate over the ResultSet
            Iterator it = resultIn.iterator();
           
            int       count   = 0;
            object[] result = null;
            while (it.hasNext()) {
                result = (object[])it.next(); 
                if (count < getExpectedRows().Length) {

//                    String[] expectedFields = getExpectedRows()[count].split(delim);
                    String[] expectedFields =
                        StringUtil.split(getExpectedRows()[count], delim);

                    //check that we have the number of columns expected...
                    if (resultIn.getColumnCount()
                            == expectedFields.Length) {

                        //...and if so, check that the column values are as expected...
                        int j = 0;

                        for (int i = 0; i < expectedFields.Length; i++) {
                            j = i ;

                        
                            
                            //...including null values...
                            if (result[j] == null)
                            {    //..then we have a null

                                //...check to see if we were expecting it...
                                if (!expectedFields[i].Equals(
                                        "NULL", StringComparison.InvariantCultureIgnoreCase))
                                {
                                    throw new Exception(
                                        "Expected row " + count
                                        + " of the ResultSet to contain:\n"
                                        + getExpectedRows()[count]
                                        + "\nbut field " + j
                                        + " contained NULL");
                                }
                            }
                            else
                            {
                                if( result[j] is double){
                                    double val1 = double.Parse(expectedFields[i]);
                                    double val2 = (double)result[j];
                                    if(val1 != val2)
                                        throw new Exception(
                                       "Expected (double) row " + (count + 1)
                                       + " of the ResultSet to contain:\n"
                                       + val1
                                       + "\nbut field " + j + " contained "
                                       + val2);

                                }
                                else if (result[j] is decimal)
                                {
                                    decimal val1 = decimal.Parse(expectedFields[i]);
                                    decimal val2 = (decimal)result[j];
                                    if (val1 != val2)
                                        throw new Exception(
                                      "Expected (double) row " + (count + 1)
                                      + " of the ResultSet to contain:\n"
                                      + val1
                                      + "\nbut field " + j + " contained "
                                      + val2);

                                }
                                else if (result[j] is DateTime)
                                {
                                    DateTime val1 = DateTime.Parse(expectedFields[i]);
                                    DateTime val2 = (DateTime)result[j];
                                    if (val1 != val2)
                                        throw new Exception(
                                      "Expected (DateTime) row " + (count + 1)
                                      + " of the ResultSet to contain:\n"
                                      + val1
                                      + "\nbut field " + j + " contained "
                                      + val2);

                                }
                                else
                                {
                                    String actual = result[j] == null ? null : Convert.ToString(result[j]); 
                                    if (!actual.Equals(expectedFields[i]))
                                    {

                                        //then the results are different
                                        throw new Exception(
                                            "Expected row " + (count + 1)
                                            + " of the ResultSet to contain:\n"
                                            + getExpectedRows()[count]
                                            + "\nbut field " + j + " contained "
                                            + (string)result[j]);
                                    }
                                }
                            }
                        }
                    } else {

                        //we have the wrong number of columns
                        throw new Exception(
                            "Expected the ResultSet to contain "
                            + expectedFields.Length
                            + " fields, but it contained "
                            + resultIn.getColumnCount()
                            + " fields.");
                    }
                }

                count++;
            }

            //check that we got as many rows as expected
            if (count != getExpectedRows().Length) {

                //we don't have the expected number of rows
                throw new Exception("Expected the ResultSet to contain "
                                    + getExpectedRows().Length
                                    + " rows, but it contained " + count
                                    + " rows.");
            }
        } catch (Exception x) {
            message = x.Message;

            return false;
        }

        return true;
    }

    private String[] getExpectedRows() {
        return expectedRows;
    }
}

/** Represents a ParsedSection for an update test */
class UpdateParsedSection : ParsedSection {

    //expected update count
    int countWeWant;

    internal UpdateParsedSection(String[] lines)
        : base(lines)
    {

       

        type        = 'u';
        countWeWant = int.Parse(lines[0]);
    }

    internal override String getResultString()
    {
        return getCountWeWant().ToString();
    }

    private int getCountWeWant() {
        return countWeWant;
    }

    internal override bool test(SessionInterface aStatement)
    {

        Result resultOut = new Result(ResultConstants.SQLEXECDIRECT);
        resultOut.setMainString(getSql());
        resultOut.setMaxRows(1000);

        Result resultIn = null;

        try {
            try {

                //execute the SQL
                resultIn = aStatement.execute(resultOut);
            } catch (Exception s) {
                throw new Exception("Expected an update count of "
                                    + getCountWeWant()
                                    + ", but got the error: "
                                    + s.Message+ " ");
            }
            if (resultIn.isError())
            {
                throw new Exception(resultIn.getMainString() + "\n " + resultIn.getException().Message +
                                    "\n " + resultIn.getException().StackTrace);
            }

            if (resultIn.updateCount != getCountWeWant())
            {
                throw new Exception("Expected an update count of "
                                    + getCountWeWant()
                                    + ", but got an update count of "
                                    + resultIn.updateCount + ". ");
            }
        } catch (Exception x) {
            message = x.Message;

            return false;
        }

        return true;
    }
}

/** Represents a ParsedSection for silent execution */
class SilentParsedSection : ParsedSection {

    internal SilentParsedSection(String[] lines)
        : base(lines)
    {

      

        type = 's';
    }

    internal override String getResultString()
    {
        return null;
    }

    internal override bool test(SessionInterface aStatement)
    {

         Result resultOut = new Result(ResultConstants.SQLEXECDIRECT);
        resultOut.setMainString(getSql());
        resultOut.setMaxRows(1000);

        Result resultIn = null;

        try {
            resultIn = aStatement.execute(resultOut);
        } catch (Exception ) {}

        //if (resultIn.isError())
        //{
        //    Assert.Fail(resultIn.getMainString() + "\n " + resultIn.getException().Message + "\n " + resultIn.getException().StackTrace);
        //}

        return true;
    }
}

/** Represents a ParsedSection for a count test */
class CountParsedSection : ParsedSection {

    //expected row count
    private int countWeWant;

    internal CountParsedSection(String[] lines)
        : base(lines)
    {

        

        type        = 'c';
        countWeWant = int.Parse(lines[0]);
    }

    internal override String getResultString() {
        return getCountWeWant().ToString();
    }

    private int getCountWeWant() {
        return countWeWant;
    }

    internal override bool test(SessionInterface aStatement)
    {

        Result resultOut = new Result(ResultConstants.SQLEXECDIRECT);
        resultOut.setMainString(getSql());
        resultOut.setMaxRows(1000);

        Result resultIn = null;
        try {

            //execute the SQL
            try {
               resultIn = aStatement.execute(resultOut);
            } catch (Exception s) {
                throw new Exception("Expected a ResultSet containing "
                                    + getCountWeWant()
                                    + " rows, but got the error: "
                                    + s.Message);
            }
            if (resultIn.isError())
            {
                throw new Exception(resultIn.getMainString() + "\n " + resultIn.getException().Message +
                                    "\n " + resultIn.getException().StackTrace);
            }

            //check that update count != -1
            if (resultIn.updateCount != -1)
            {
                throw new Exception(
                    "Expected a ResultSet, but got an update count of "
                    + resultIn.updateCount);
            }

            //iterate over the ResultSet
            Iterator it = resultIn.iterator();
        
            int       count   = 0;

            while (it.hasNext()) {
                it.next();
                count++;
            }

            //check that we got as many rows as expected
            if (count != getCountWeWant()) {

                //we don't have the expected number of rows
                throw new Exception("Expected the ResultSet to contain "
                                    + getCountWeWant()
                                    + " rows, but it contained " + count
                                    + " rows.");
            }
        } catch (Exception x) {
            message = x.Message;

            return false;
        }

        return true;
    }
}

/** Represents a ParsedSection for an Exception test */
class ExceptionParsedSection : ParsedSection {

    internal ExceptionParsedSection(String[] lines)
        : base(lines)
    {     
        type = 'e';
    }

    internal override String getResultString()
    {
        return "Exception";
    }

    internal override bool test(SessionInterface aStatement)
    {

        Result resultOut = new Result(ResultConstants.SQLEXECDIRECT);
        resultOut.setMainString(getSql());
        resultOut.setMaxRows(1000);

        Result resultIn = null;
        try {
           resultIn =  aStatement.execute(resultOut);         
        }
        catch (Exception x) {
            message = x.Message;

            return false;
        }

        if (resultIn.isError())
        {
            return true;
        }          

        return false;
    }
}

/** Represents a ParsedSection for a section with blank type */
class BlankParsedSection : ParsedSection {

    internal BlankParsedSection(String[] lines)
        : base(lines)
    {

      
        type = ' ';
    }

    internal override String getResultString()
    {
        return "No result specified for this section";
    }
}

/** Represents a ParsedSection that is to be ignored */
class IgnoreParsedSection : ParsedSection {

    internal IgnoreParsedSection(String[] inLines, char aType)
        : base(inLines)
    {

        /* Extremely ambiguous to use input parameter of same exact
         * variable name as the superclass member "lines".
         * Therefore, renaming to inLines. */

        // Inefficient to parse this into SQL when we aren't going to use
        // it as SQL.  Should probably just be removed to use the 
        // super() constructor.
     
        type = aType;
    }

    internal override String getResultString()
    {
        return "This section, of type '" + getType() + "' was ignored";
    }
}

/** Represents a Section to be Displayed, not executed */
class DisplaySection : ParsedSection {

    internal DisplaySection(String[] inLines)
    {

        /* Can't user the super constructor, since it does funny things when
         * constructing the SQL Buffer, which we don't need. */
        lines = inLines;

        int firstSlash = lines[0].IndexOf('/');

        lines[0] = lines[0].Substring(firstSlash + 1);
    }

    internal override String getResultString()
    {

        StringBuilder sb = new StringBuilder();

        for (int i = 0; i < lines.Length; i++) {
            if (i > 0) {
                sb.Append('\n');
            }

            sb.Append("+ " + lines[i]);
        }

        return sb.ToString();
    }
}

}
